MajdiB

The Outbox Pattern

The Outbox Pattern: How to Lie to Your Database and Still Sleep at Night

The dual-write problem is the silent killer of event-driven systems: you save to your database, then publish an event, and one of those two things eventually fails without the other knowing. The Outbox Pattern fixes it by lying to your database on purpose, in a way that's actually safe.

The Two-Writes Problem

Here's a piece of code that looks completely reasonable:

javascript
await db.orders.save(order);
await messageBroker.publish("order.created", order);

It works in every demo, every local test, and every happy path in staging. Then, six months into production, the broker connection times out for 400ms during a deploy, the second line throws, and you're left with an order that exists in your database and nowhere else. No event, no downstream side effects, no inventory reservation, no confirmation email. The order is real. The rest of the system doesn't know it.

You can't wrap a database write and a network call to a broker in the same transaction — they're two different systems with two different failure modes. Whatever order you do them in, there's a window where one has happened and the other hasn't. That window is where 3 AM pages are born.

The Lie: Write the Event to the Database Too

The Outbox Pattern's trick is refusing to treat "save data" and "publish event" as two operations in the first place. Instead, you write the event into a table in the same database, same transaction as the business data it describes:

sql
BEGIN;

INSERT INTO orders (id, customer_id, status, total)
VALUES ('ord_123', 'cus_9', 'created', 4200);

INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at)
VALUES ('evt_456', 'ord_123', 'order.created', '{"orderId":"ord_123","total":4200}', now());

COMMIT;

This is the lie: as far as the database is concerned, "the order exists" and "the event that says the order exists" are the same fact. They either both commit or both roll back. There is no window anymore — because there's no second system involved yet. You've turned a distributed transaction problem into a single-node ACID guarantee.

Of course, the event is still sitting in a table, not on your message broker. That's the second half of the pattern.

Getting It Out of the Table Without Reintroducing the Problem

A separate process — a poller or, better, a change-data-capture (CDC) reader like Debezium watching the database's write-ahead log — reads unpublished rows from the outbox table and publishes them to the broker, then marks them as sent:

javascript
const pending = await db.outbox.findUnpublished({ limit: 100 });

for (const event of pending) {
    await messageBroker.publish(event.event_type, event.payload);
    await db.outbox.markPublished(event.id);
}

Notice the failure mode here is completely different from the original problem. If the process crashes after publishing but before marking the row as sent, you publish the same event twice on restart. That's a duplicate, not data loss — and duplicates are a solved problem (make your consumers idempotent, which you should be doing anyway in any event-driven system). Losing an event silently is a much harder problem to detect, let alone fix, than receiving it twice.

This is the actual shape of the pattern: you don't eliminate the two-writes problem, you move it to a place where the failure mode is safe. At-least-once delivery from a durable log beats exactly-once-or-nothing between two independent systems, every time.

When You Don't Need This

If your "event" is really just an internal call within the same service and the same database transaction — updating a second table, say — you don't need an outbox, you need a transaction. The outbox earns its complexity specifically at the boundary between your database and something outside your transactional control: a message broker, another service's API, a webhook target.

It also assumes your consumers can tolerate at-least-once delivery and occasional out-of-order arrival unless you add sequencing. If your downstream systems genuinely need strict ordering and exactly-once semantics, the outbox is necessary but not sufficient — you'll also need partition keys and idempotency keys on the consuming side.

The Takeaway

The Outbox Pattern doesn't make the dual-write problem disappear. It relocates it from "database commit vs. network call to a different system" — which can never be made atomic — to "publish vs. mark-as-published within the same durable log," which can be made safe with idempotency and retries. You're still lying to your database, in the sense that the event row isn't really "data," it's a promise to tell someone else later. But it's a lie your database is perfectly equipped to keep.

FAQ

Why can't I just use a distributed transaction (two-phase commit) instead?

Most message brokers (Kafka included) don't support XA/two-phase commit, and even when a broker does, two-phase commit is slow, fragile under network partitions, and couples the availability of your database to the availability of your broker. The Outbox Pattern avoids the problem entirely by never needing a cross-system transaction in the first place.

Do I need Debezium, or can a simple polling loop work?

A polling loop works fine at low-to-moderate volume and is much simpler to operate. Change-data-capture tools like Debezium become worth the operational overhead once polling latency or database load from frequent polling becomes a real bottleneck.

What happens if the same event gets published twice?

That's expected and safe as long as your consumers are idempotent — typically by tracking processed event IDs and skipping duplicates. At-least-once delivery with idempotent consumers is the standard, reliable combination; chasing exactly-once delivery end-to-end is usually not worth the complexity.

Does the outbox table grow forever?

No — once an event is confirmed published, it can be archived or deleted. Most implementations keep a rolling window of recently published events for debugging and delete or move older rows to cold storage on a schedule.

Can I use the Outbox Pattern with a NoSQL database?

Yes, as long as the database supports atomic writes across the business record and the outbox record — for example, a single-document write in MongoDB that includes both the state change and an embedded outbox array. Without that atomicity guarantee, you're back to the original dual-write problem.

We use cookies on this site to enhance your user experience

By clicking the Accept button, you agree to us doing so. More info on our cookie policy